import { NextResponse } from 'next/server' import { z } from 'zod' import { createLogger } from '@open-mercato/shared/lib/logger' import type { EntityManager } from '@mikro-orm/postgresql' import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi' import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server' import { createRequestContainer } from '@open-mercato/shared/lib/di/container' import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope' import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe' import { AiModelFactoryError } from '@open-mercato/ai-assistant/modules/ai_assistant/lib/model-factory' import { validateCrudMutationGuard, runCrudMutationGuardAfterSuccess } from '@open-mercato/shared/lib/crud/mutation-guard' import { agentRunRequestSchema, baseAgentResultSchema, guardrailKind, guardrailPhase } from '../../../../data/validators' import { AgentProposal } from '../../../../data/entities' import { AgentGuardrailBlockedError, AgentNotFoundError, AgentOutputInvalidError, AgentRunTimeoutError, type AgentRunCtx, type AgentRuntimeService, } from '../../../../lib/runtime/agentRuntime' import { isAgentCapacityError, resolveAdmissionMaxWaitMs } from '../../../../lib/runtime/admission' export const metadata = { POST: { requireAuth: true, requireFeatures: ['agent_orchestrator.agents.run'] }, } const logger = createLogger('agent_orchestrator').child({ component: 'agent-run-api' }) const errorSchema = z.object({ error: z.string() }) /** 422 body for a guardrail `block` verdict — distinct from plain invalid output. */ const guardrailBlockedErrorSchema = errorSchema.extend({ code: z.literal('guardrail_blocked'), kind: guardrailKind, phase: guardrailPhase, guardrailSetVersion: z.string().nullable(), }) /** 500 body for a failure this route could not classify — it names itself. */ const unclassifiedErrorSchema = errorSchema.extend({ code: z.literal('agent_run_failed'), reason: z.string(), }) /** 503 body for a deployment whose LLM provider is missing or unusable. */ const modelUnavailableErrorSchema = errorSchema.extend({ code: z.enum(['no_provider_configured', 'api_key_missing', 'provider_rejected']), }) /** * Did the configured provider REFUSE the call for a reason that is about this * deployment rather than about the request? * * Detected structurally, the way the runtime already recognises a guardrail * block and a capacity error, so this module keeps importing no AI SDK. The * status set is deliberately narrow: 401/402/403 are authentication and billing, * 429 is exhausted quota. A plain 400 is NOT included — that is where a genuinely * malformed request from our own schema would land, and labelling that an * environment problem would hide our bug — except for the one unmistakable case * where the provider spends a 400 on a billing state (Anthropic answers "credit * balance is too low" that way). */ const PROVIDER_REFUSAL_STATUSES = new Set([401, 402, 403, 429]) const BILLING_REFUSAL = /credit balance|billing|insufficient[_ ]quota|payment required/i const MODEL_FACTORY_ERROR_CODES = new Set(['no_provider_configured', 'api_key_missing']) /** * Is this the model factory saying the deployment cannot resolve a model? * * `instanceof` alone is not enough: the run executes behind the admission queue, * and by the time the error surfaces here it no longer carries the factory's * prototype — CI caught a pinned-provider failure ("the model is pinned to * openai, but that provider is not configured") falling past the 503 branch into * the unclassified 500. The name and the code survive that crossing, so they are * what we match on, the same way the provider-refusal check above works. */ function asModelFactoryError(err: unknown): { code: string } | null { if (err instanceof AiModelFactoryError) return { code: err.code } if (!err || typeof err !== 'object') return null const candidate = err as { name?: unknown; code?: unknown } if (candidate.name !== 'AiModelFactoryError') return null const code = typeof candidate.code === 'string' && MODEL_FACTORY_ERROR_CODES.has(candidate.code) ? candidate.code : 'no_provider_configured' return { code } } function isProviderRefusal(err: unknown): boolean { if (!err || typeof err !== 'object') return false const candidate = err as { name?: unknown; statusCode?: unknown; responseBody?: unknown } if (candidate.name !== 'AI_APICallError') return false const status = typeof candidate.statusCode === 'number' ? candidate.statusCode : null if (status !== null && PROVIDER_REFUSAL_STATUSES.has(status)) return true const body = typeof candidate.responseBody === 'string' ? candidate.responseBody : '' return status === 400 && BILLING_REFUSAL.test(body) } type RouteContext = { params: Promise<{ id: string }> } export async function POST(req: Request, ctx: RouteContext) { const auth = await getAuthFromRequest(req) if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) if (!auth.tenantId || !auth.sub) { return NextResponse.json({ error: 'Tenant context required' }, { status: 400 }) } const { id } = await ctx.params const body = await readJsonSafe(req, {}) const parsed = agentRunRequestSchema.safeParse(body) if (!parsed.success) { return NextResponse.json( { error: 'Validation failed', fieldErrors: parsed.error.flatten().fieldErrors }, { status: 422 }, ) } const container = await createRequestContainer() // Attribute the run to the concretely selected organization, resolved through // the canonical scope resolver (the same one the caseload reads with). Raw // `auth.orgId` is not trustworthy here: under the "All organizations" scope it // is either null (superadmin) or a stale account/home org, which would stamp an // AgentRun/AgentProposal under an org the caseload never queries — silently // orphaning the proposal from human disposition (#3629). Fail closed instead. const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req }) const organizationId = scope?.selectedId ?? null if (!organizationId) { return NextResponse.json( { error: 'Select a single organization before running an agent. Agent runs must be attributed to one organization so the resulting proposal is reviewable in the caseload.', }, { status: 400 }, ) } const guardResult = await validateCrudMutationGuard(container, { tenantId: auth.tenantId, organizationId, userId: auth.sub, resourceKind: 'agent_orchestrator.agent_run', resourceId: id, operation: 'custom', requestMethod: 'POST', requestHeaders: req.headers, }) if (guardResult && !guardResult.ok) { return NextResponse.json(guardResult.body, { status: guardResult.status }) } // Capture the persisted run id (navigation spec §1): the runners fire // `onRunPersisted` for every run they create — nested sub-agent delegations // included — so keep only the FIRST invocation, which is the top-level run. let observedRunId: string | null = null const runCtx: AgentRunCtx = { tenantId: auth.tenantId, organizationId, userId: auth.sub, onRunPersisted: (persistedRunId) => { if (!observedRunId) observedRunId = persistedRunId }, } let result: unknown try { const agentRuntime = container.resolve('agentRuntime') as AgentRuntimeService result = await agentRuntime.run(id, parsed.data.input, runCtx) } catch (err) { if (err instanceof AgentNotFoundError) { return NextResponse.json({ error: 'Agent not found', code: 'agent_not_found' }, { status: 404 }) } // Subclass FIRST: a guardrail block is a policy verdict, not a model bug — // the typed reason (kind/phase/set version) must reach the client instead // of the generic invalid-output message (data-honesty spec §3.6). if (err instanceof AgentGuardrailBlockedError) { return NextResponse.json( { error: 'Blocked by a runtime guardrail', code: 'guardrail_blocked', kind: err.kind, phase: err.phase, guardrailSetVersion: err.guardrailSetVersion ?? null, }, { status: 422 }, ) } if (err instanceof AgentOutputInvalidError) { return NextResponse.json( { error: 'Agent produced invalid output', code: 'agent_output_invalid' }, { status: 422 }, ) } if (err instanceof AgentRunTimeoutError) { return NextResponse.json( { error: 'The agent run timed out before producing a result', code: 'agent_run_timeout' }, { status: 422 }, ) } if (isAgentCapacityError(err)) { const retryAfterSeconds = Math.max(1, Math.ceil(resolveAdmissionMaxWaitMs() / 1000)) return NextResponse.json( { error: 'Agent run capacity is exhausted — retry shortly', code: 'agent_capacity_exhausted' }, { status: 429, headers: { 'Retry-After': String(retryAfterSeconds) } }, ) } // A deployment with no LLM provider (or a provider whose key never // resolved) is a MIS-PROVISIONED ENVIRONMENT, not a bad request and not a // bad model answer — so it is 503, not one of this route's 422s, which all // mean "the run happened and its outcome is unusable". Mirrors the mapping // the ai_assistant routing endpoint already uses for the same error. const modelFactoryError = asModelFactoryError(err) if (modelFactoryError) { return NextResponse.json( { error: 'No LLM provider is configured for this deployment', code: modelFactoryError.code }, { status: 503 }, ) } // A provider that refuses the call — no credit, revoked key, exhausted quota — // leaves this deployment unable to run a model, exactly like a provider that // was never configured. Same 503, own code, so a caller can tell "nothing is // wired" from "what is wired will not serve us". if (isProviderRefusal(err)) { logger.error('Agent run refused by the LLM provider', { agentId: id, runId: observedRunId, err }) return NextResponse.json( { error: 'The configured LLM provider refused the request for this deployment', code: 'provider_rejected' }, { status: 503 }, ) } // Anything past the typed mappings above used to rethrow into an // empty-bodied 500: unreadable from the client, and — because the app runs // as its own process whose output the harness captures rather than prints — // invisible in CI too. A failing run then reported only "expected 200, // received 500", which names nothing. // // It answers with its own body now, carrying the error's name and a // truncated message. This surface is gated on `agents.run`, the message is // operational detail ("provider refused", "column does not exist"), and the // provider's response body — which echoes the request — is never included. logger.error('Agent run failed with an unclassified error', { agentId: id, runId: observedRunId, err, }) const reason = err instanceof Error ? `${err.name}: ${err.message}` : String(err) return NextResponse.json( { error: 'The agent run failed unexpectedly', code: 'agent_run_failed', reason: reason.slice(0, 300), }, { status: 500 }, ) } if (guardResult?.shouldRunAfterSuccess) { await runCrudMutationGuardAfterSuccess(container, { tenantId: auth.tenantId, organizationId, userId: auth.sub, resourceKind: 'agent_orchestrator.agent_run', resourceId: id, operation: 'custom', requestMethod: 'POST', requestHeaders: req.headers, metadata: guardResult.metadata, }) } // Additive sibling fields next to the typed result (navigation spec §1): the // `AgentResult` union never defines `runId`/`proposalId`, so spreading is // collision-free and existing consumers reading `kind`/`proposal`/`data` are // unaffected. Id-only projection — no encrypted proposal columns are fetched. let proposalId: string | null = null if (observedRunId) { const em = (container.resolve('em') as EntityManager).fork() const proposals = await em.find( AgentProposal, { runId: observedRunId, tenantId: auth.tenantId, organizationId, deletedAt: null }, { orderBy: { createdAt: 'desc' }, limit: 1, fields: ['id'] }, ) proposalId = proposals[0]?.id ?? null } const resultRecord = (result && typeof result === 'object' ? result : {}) as Record return NextResponse.json({ ...resultRecord, runId: observedRunId, proposalId }) } export const openApi: OpenApiRouteDoc = { tag: 'Agent Orchestrator', summary: 'Run an agent (Playground — engineering primitive)', methods: { POST: { summary: 'Run one agent directly (Playground, evals, diagnostics)', description: 'An ENGINEERING primitive, not a business-orchestration one. It runs a single agent synchronously and returns what it produced; it has no retry, no wait states, no signals, no cancellation and no durable business lifecycle, because an AgentRun records an agent execution rather than a business process. Use it for the Playground, development, evals, diagnostics and sub-agent invocation. To start durable business work — from an integration, a schedule, an event or by hand — call POST /processes/{id}/executions and observe agent_orchestrator.process.execution.* instead; a caller coupled to an agent id cannot be refactored around. Runs the agent in object mode under the caller scope, persists an AgentRun (and an AgentProposal for proposal results), and returns the typed AgentResult plus additive sibling fields: `runId` (the persisted AgentRun id) and `proposalId` (the newest AgentProposal created by the run, null for researcher runs).', requestBody: { contentType: 'application/json', schema: agentRunRequestSchema, description: 'Agent input payload (shape is agent-specific).', }, responses: [ { status: 200, description: 'Typed AgentResult + { runId, proposalId }', schema: baseAgentResultSchema.and( z.object({ runId: z.string().uuid().nullable(), proposalId: z.string().uuid().nullable() }), ), }, ], errors: [ { status: 400, description: 'Tenant context missing, or no single organization is selected (run under "All organizations" is rejected)', schema: errorSchema }, { status: 401, description: 'Unauthorized', schema: errorSchema }, { status: 403, description: 'Missing agent_orchestrator.agents.run', schema: errorSchema }, { status: 404, description: 'Unknown agent id', schema: errorSchema }, { status: 422, description: 'Invalid input, invalid model output, run wall-clock timeout — or a runtime guardrail block, in which case the body carries `code: "guardrail_blocked"` plus the typed `kind`/`phase`/`guardrailSetVersion` reason', schema: z.union([errorSchema, guardrailBlockedErrorSchema]), }, { status: 429, description: 'Agent run capacity exhausted (admission control); includes Retry-After', schema: errorSchema }, { status: 500, description: 'The run failed for a reason this route does not classify; the body carries `code` (`agent_run_failed`) and a truncated `reason`', schema: unclassifiedErrorSchema, }, { status: 503, description: 'The deployment has no usable LLM provider; the body carries `code` (`no_provider_configured`, `api_key_missing`, or `provider_rejected` when a configured provider refuses the call)', schema: modelUnavailableErrorSchema, }, ], }, }, }